深入理解Android系统资源异常之文件描述符异常篇
一、引言
本文的目标是帮助大家深入理解Android系统资源异常之文件描述符异常,对于文件描述符异常的通用检测机制,当前包括fdtrack和fdsan两种机制展开剖析。
通过阅读本篇文章,期望读者可以了解到:
1)什么是文件描述符
2)linux kernel中如何使用文件描述符,来管理进程打开文件资源
3)android fdsan机制设计思路与实现
4)android fdtrack机制设计思路与实现
二、背景知识
1. 什么是文件描述符
文件描述符,即file descriptor,缩写为fd。
对于linux内核,所有打开的文件都是通过文件描述符引用,文件描述符实现为一个非负整数。
linux的设计哲学是一切兼文件。这句话的意思是,所有的系统资源都可以通过文件IO的方式进行访问。这就凸显了作为索引的文件描述符的重要性。
2. 获取fd的时机
当打开一个现有的文件,或创建一个新文件时,内核会向进程返回一个文件描述符。
当读、写一个文件时,使用open/create返回的文件描述符来标识该文件,将其作为参数传递给read或write。
3. fd取值范围的限制
文件描述符取值范围在[0~OPEN_MAX - 1],在早期的操作系统实现中OPEN_MAX取值很小,但对于现代的操作系统实现,文件描述符的变化范围几乎是无限制的,只受到系统的硬件配置、整型的字长以及系统管理员配置的软、硬限制的约束。
POSIX语义中,0、1、2这三个文件描述符被标准赋予特殊含义,分别指代标准输入STDIN_FILENO、标准输出STDOUT_FILENO、标准错误STDERR_FILENO。
系统中对每个进程可以打开最大fd数量的限制,可以通过命令ulimit -n查询。
也可以加上-S标识软限制,-H标识硬限制。
我这里分别查询了手机系统和服务器系统上的数值,手机系统上为 32768;服务器系统上为 20480000。
那么,手机上的这个fd最大限制数值是怎么来的呢?
标准linux实现中,在头文件include/uapi/linux/fs.h中有宏定义,标识了系统默认的软、硬限制。
#define INR_OPEN_CUR 1024 /* Initial setting for nfile rlimits */
#define INR_OPEN_MAX 4096 /* Hard limit for nfile rlimits */
android系统在core/rootdir/init.rc文件中,on early-init时候进行了重新设定。
# Allow up to 32K FDs per process
setrlimit nofile 32768 32768
4. 内核如何使用fd管理进程打开的文件
我们知道进程是操作系统资源管理的基本单元。
linux内核中使用struct task_struct来描述进程。
(1) struct task_struct
task_struct结构体中有一个字段files,对应的struct files_struct结构体用于管理进程打开的文件资源。
下面列出了task_struct中与文件资源管理相关的核心字段。
struct task_struct {
/* Open file information: */
struct files_struct *files;
};
可以看到files字段是一个指针,指向了一个struct files_struct的结构体。
(2) struct files_struct
files_struct结构体用于管理进程打开的所有文件资源。采用数组的方式来管理进程打开的文件,fd(非负整数)就作为数组的索引。
可以看到数组分布在两个地方:
1)struct file __rcu * fd_array[NR_OPEN_DEFAULT],是直接包含在files_struct结构体的静态数组部分,在64位系统上,NR_OPEN_DEFAULT对应为64;
2)struct fdtab,是一个动态数组结构,数组没有直接包含在files_struct结构体中,是根据需要动态分配的。
这种静态分配加动态扩展的方式,是软件设计中的常用技巧,是对性能与资源的tradeoff。
对于大部分只打开少量文件的进程来说,静态数组就可以满足需求。对于少部分需要打开更多文件的进程,当打开文件数量超过了静态数组的阈值后,会动态分配fdtab数组来进行扩展。
(3) struct fdtab
fdtab结构体是封装动态扩展fd数组的,其中关键字段为fd和max_fds。
fd字段是一个二级指针,指向一个数组(动态分配),数组元素类型为struct file *,与上面的静态数组fd_array一样。max_fds字段表明这个动态数组的大小。
再次重复强调一下,文件描述符fd,就是一个数组索引,用于索引进程(files_struct结构体中)打开的文件。
(4) struct file
fd本质是进程中的一个数组索引,索引的数组元素类型为struct file *,而struct file才是表示文件信息的正主。
下面列出了struct file结构体中,需要重点关注的几个字段。
struct file {
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
loff_t f_pos;
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
f_path用来表示文件名;
f_inode用来关联文件系统信息,这里的inode是vfs的inode类型,是基于具体的文件系统之上一层通用抽象;
f_pos表示当前文件的偏移,在进行实际IO的时候非常重要。f_pos会在open的时候设置成默认值,seek的时候修改为指定值。
(5) 文件描述符与文件关系
需要注意的是,struct files_struct结构体归属于某个进程,所以fd是进程内部的资源,用于管理本进程内打开的文件。
struct file结构体是系统级别,不归属于单个进程。多个进程可以打开同一个文件,使用自身的fd资源索引该文件。
下面引用自参考资料[2]中的一张图片来直观说明这种关系:
三、fdsan机制介绍
1. fdsan简介
(1) fdsan是什么
file descriptor sanitizer的缩写,是Android在Q版本中引入的一种文件描述符异常检测机制。
(2) fdsan可以干什么
可以检测fd ownership mis-handling这种类型的错误。
(3) fdsan怎么用
Android在Q版本引入的针对fd ownership mid-handling的异常检测机制。代码固化在bionic的libc库。在通过linker加载libc库时,fdsan相关初始化代码会自动导入。
所有包含了libc库的共享库以及可执行程序,已经包含了fdsan的基础设施,只要在代码中使用fdsan提供的API来检查文件打开与关闭操作即可:
1)android_fdsan_exchange_owner_tag,在打开文件后,紧接着调用该API设置owner tag
2)android_fdsan_close_with_tag,在关闭文件前,调用该API进行fdsanitizer检测
需要注意的是,fdsan的基础设施固化在libc库中,所以没有包含libc库的共享库或者可执行程序,无法使用该检测机制提供的能力。
当前AOSP代码中共享库与可执行程序已经包含了对fdsan的使用。
使用范例可以参考AOSP代码中的libziparchive实现。
在构造函数中,增加android_fdsan_exchange_owner_tag的调用。
在析构函数中增加android_fdsan_close_with_tag的调用。
其中通过GetOwnerTag,我们可以看到对于owner tag的构造。
这样所有使用的libziparchive的代码就包含了对于ANDROID_FDSAN_OWNER_TYPE_ZIPARCHIVE 类型fd的sanitizer检测。
2. 涉及代码路径
bionic/docs/fdsan.md
bionic/libc/include/android/fdsan.h
bionic/libc/private/bionic_fdsan.h
bionic/libc/bionic/fdsan.cpp
bionic/tests/fdsan_test.cpp
其中fdsan.md,是fdsan的manual文件;
fdsan.h,是fdsan方案的公共头文件,包含了API接口原型声明以及tag类型的枚举定义;
bionic_fdsan.h,是fdsan方案内部私有头文件,定义了进程内部存储fd关联tag信息的数据结构,按照静态数组(128)加动态扩展数组方式来实现存储结构;
fdsan.cpp,fdsan方案的实现文件;
fdsan_test.cpp,fdsan方案测试代码,针对各种类型的fd ownership mis-handling的模拟故障测试代码。
3. 设计思路解读
fdsan的设计思路浅显易懂:
1)打开已有文件或创建一个新文件的时候,在得到返回fd后,设置一个关联的tag,来标记fd的属主信息;
关闭文件前,检测fd关联的tag,判断是否符合预期(属主信息一致),符合就继续走正常文件关闭流程;如果不符合就是检测到异常,根据设置,调用对应的异常处理。
4. 实现代码解读
通过代码注释,可以看到如果没有主动设置tag,则文件打开相关操作,默认tag为0,关闭的时候也按照对应的默认tag(0)来做匹配检测。
(1) 关联tag格式定义
fdsan内部实现,使用struct FdEntry来表示与fd关联的tag,可以看到实际上就是一个u64原子类型的整数。
struct FdEntry {
_Atomic(uint64_t) close_tag = 0;
};
还有一个关键的枚举android_fdsan_owner_type给出了tag格式的解读。
定义在bionic/libc/include/android/fdsan.h头文件中。
可以看到将64bit的tag数据拆分为两个部分:最高字节用于标识type类型,剩下字节用于标识实际的owner tag。
从注释可以看到,android当前预定义了12种type,这12种之外的其他java对象,以及native指针type域都会对应到255。
对于通用java对象,type域定义为255,并使用对象的hashcode作为tag域的值;
对于native指针,整个close_tag取值为48bit虚拟地址的符号扩展,type域的值正好也是255,并且可以使用bit49~56的值来区分是native指针还是通用java对象类型。
这个可以从内部函数android_fdsan_get_tag_type的实现中,得到很好的解读。
(2) 实现内部存储tag数据结构定义
定义在bionic/libc/private/bionic_fdsan.h头文件中。
tag存储实体struct FdEntry定义:
动态扩展数组结构struct FdTableOverflow定义:
FdTable数据结构模板FdTableImpl定义:
包含如下重要成员:
error_level,用于控制检测到异常后的处理行为,放到该结构体里,可以实现每进程细粒度控制;
entries数组,静态数组,大小由模板特化的时候参数传入,对于大多数进程来说,需要打开文件数量有限,静态数组就可以满足存储需求;
overflow,动态数组,当进程打开文件数量超出静态数组阈值时候,动态分配;
at,成员函数,用于根据fd做索引,返回对应的tag值;
模板特化类型FdTable定义,指定静态数组大小为128。
using FdTable = FdTableImpl<128>;
全局变量fd_table定义在头文件bionic/libc/private/bionic_globals.h中
通过注释,可以知道libc_shared_globals结构体会在动态链接libc共享库时候得到构造,其中成员fd_table也就会被构造。
(3) 初始化
fdsan模块的初始化函数__libc_init_fdsan,完成fdsan特性属性debug.fdsan设置到内部数据fd_table中。
__libc_init_fdsan会在libc的初始化流程中被调用到。调用点位于bionic/libc/bionic/libc_init_common.cpp文件中的__libc_init_common
从__libc_init_common的调用点,我们可以明白fdsan初始化生效逻辑。
bionic/libc/bionic/libc_init_dynamic.cpp文件中的__libc_preinit_impl;
bionic/libc/bionic/libc_init_static.cpp文件中的__real_libc_init。
可见不管是静态链接libc还是动态链接libc,只要是链接了libc库的进程,都会保证fdsan的初始化流程的执行。
下面从fdsan对外暴露的三个API来剖析fdsan的内部实现。
(4) android_fdsan_create_owner_tag
通过传入的type和tag字段,拼接成一个有效的close_tag值。
然后调用android_fdsan_exchange_owner_tag进行ownership的设定。
(5) android_fdsan_exchange_owner_tag
入参说明:
fd,fd句柄,作为FdEntry的索引
expected_tag,期望的ownership tag值
new_tag,设置新的ownership tag值
通过fd索引找到对应的FdEntry,判断tag值是否与expected_tag一致,一致说明ownership符合预期,可以使用new_tag值重新设定对应的FdEntry。
比较与设置操作通过原子操作atomic_compare_exchange_strong完成,可以保证是线程安全的。
如果不符合ownership预期,则说明检测到了异常,根据expected_tag和FdEntry tag关系调用fdsan_error进行错误处理。
(6) android_fdsan_close_with_tag
入参说明:
fd,待关闭的fd句柄
expected_tag,期望的ownership tag,如果与fd对应的FdEntry匹配,则执行正常关闭操作,否则,说明检测到异常,进行错误处理。
其中FDTRACK_CLOSE在fdtrack章节4.4.1进行介绍。
tag匹配检查操作也是通过原子操作atomic_compare_exchange_strong完成,保证线程安全。
如果close_tag和expected_tag相等,符合预期,可以继续调用__close执行关闭操作;
否则检测到异常,根据close_tag和expected_tag的关系,调用fdsan_error进行错误处理。错误处理相关代码足够清晰,就不用赘述了。
如果ownership匹配,但是调用__close返回失败,再进行判断,是否发生了double close。
(7) fdsan_error
根据设定的error_level ,进行异常处理。
如果是ANDROID_FDSAN_ERROR_LEVEL_DISABLED,do nothing;
如果是ANDROID_FDSAN_ERROR_LEVEL_WARN_ONCE,打印一次告警信息,然后重新设定error_level为ANDROID_FDSAN_ERROR_LEVEL_DISABLED
如果是ANDROID_FDSAN_ERROR_LEVEL_WARN_ALWAYS,总是打印告警信息;
如果是ANDROID_FDSAN_ERROR_LEVEL_FATAL,直接发送abort信号自杀。
四、fdtrack机制介绍
1. fdtrack简介
(1) fdtrack是什么
fdtrack是android在R版本开始引入,为进程fd资源泄露问题,提供一套统一的检测机制。
(2) fdtrack可以干什么
fdtrack使能后,可以检测进程fd资源泄露问题,检测到fd资源泄露后,可以打印出fd分配路径的调用栈,辅助问题的定位。
(3) fdtrack怎么用
fdtrack的实现方式中,固化了一部分桩代码到libc中,主要检测代码则实现在一个共享库libfdtrack中。要使用fdtrack功能的进程,需要动态的加载libfdtrack库来使能fdtrack功能。
android中给出了libfdtrack如何使用的示例代码:
在system_server进程中使能fdtrack检测功能,基本思路是,通过进程内已分配的fd资源的绝对数量来决定何时启用fdtrack功能,以及判定何时发生了fdleak。
创建一个monitor线程,周期性的检测进程fd资源是否超过了预定的阈值,当超过第一个检测trigger阈值时,主动加载libfdtrack库,使能fdtrack功能;当继续超过第二个泄露阈值时,会发送信号BIONIC_SIGNAL_FDTRACK,调用到libfdtrack库中信号处理函数进行异常处理。
下面让我们一下看一看,system_server使用fdtrack的具体示例代码吧。
在system_server主线程run函数中,调用spawnFdLeakCheckThread创建一个moniter线程,默认只在debug版本中打开。
if (Build.IS_DEBUGGABLE) {
spawnFdLeakCheckThread();
}
创建monitor线程的实现,android R版本中是通过JNI接口调用native实现,android S版本中是直接实现在java端了,我们看一下S版本中的实现。
两个阈值可以通过属性重新设定,检测trigger阈值默认为1024,泄露阈值默认为2048,检测周期默认值设置为120秒。
当fd首次超过检测阈值时,动态加载libfdtrack共享库,使能fdtrack检测;
当fd超过泄露阈值时,调用fdtrackAbort进行异常处理。
其中有个小优化项:每个检测周期,如果fd超过检测阈值时,先尝试主动GC进行一次清理,看是否改善,如果清理完成后,fd还是超过检测阈值,就会走上面描述的逻辑。
2. 涉及代码路径
android/bionic/libc/platform/bionic/fdtrack.h
android/bionic/libc/private/bionic_fdtrack.h
android/bionic/libfdtrack/fdtrack.cpp
android/bionic/libc/bionic/fdtrack.cpp
其中fdtrack.h,是fdtrack方案的公共头文件,包含了API接口原型声明,fdtrack_event的定义以及fdtrack_event_type的枚举定义;
bionic_fdtrack.h,是fdtrack方案内部私有头文件,定义了fdtrack在libc里内部嵌入的包装宏定义FDTRACK_CREATE_NAME、FDTRACK_CLOSE;
libc/bionic/fdtrack.cpp,是fdtrack方案固化在libc部分的实现代码。包括初始化部分,以及钩子函数设置,以及线程内部fdtack使能设置函数;
libfdtrack/fdtrack.cpp,是fdtrack方案libfdtrack的实现部分。
3. 设计思路解读
fdtrack的设计思路也比较直观明了:
通过预先在libc代码中埋伏好钩子函数(所有文件打开相关的API接口已经预先埋好桩),通过FDTRACK_CREATE_NAME包装宏实现埋桩。
埋桩点是否生效,是通过钩子函数__android_fdtrack_hook是否有效来控制,而钩子函数又是通过libfdtrack共享库的加载来动态赋有效值的。
这就非常好的实现了动态控制。需要使用fdtrack功能的时候,动态加载libfdtrack库,设置有效钩子函数,激活埋桩点代码。否则埋桩点代码执行空语句,不增加运行负载。
加载libfdtrack共享库后,以后的每次文件打开操作,都会调用到fdtrack内部实现代码,进行调用栈记录;每次文件关闭操作,也会调用到fdtrack内部实现代码,进行调用栈移除操作;
最后如果发生了fd泄露,只需要打印出内部记录的调用栈信息,即可辅助fd泄露问题的分析定位。
4. 实现代码解读
(1) 预埋桩代码解读
钩子函数__android_fdtrack_hook定义在libc/bionic/fdtrack.cpp中。
_Atomic(android_fdtrack_hook_t) __android_fdtrack_hook;
是一个指向android_fdtrack_hook_t类型的原子类型变量,默认初始值为空指针,只有当libfdtrack加载后,才会被赋予有效值fd_hook。
文件打开与关闭API包装宏,功能与用法通过注释可以看到。
包装宏FDTRACK_CREATE_NAME可以自己指定name参数,包装参数到fdtrack_event,调用__android_fdtrack_hook处理fdtrack_event。
包装宏FDTRACK_CREATE直接使用包装宏的调用函数名作为name参数。
只有加载了libfdtrack共享库,才会将__android_fdtrack_hook设置为有效值,满足非空条件,执行hook函数,从而进入到fdtrack内部实现。
包装宏FDTRACK_CLOSE,仅包装参数到fdtrack_event,调用__android_fdtrack_hook处理fdtrack_event,完成fdtrack调用栈信息记录的闭环,不真实执行close操作。
FDTRACK_CREATE_NAME埋桩调用点,通过搜索代码,可以看到预先埋到了各个file descriptor creation API接口函数中
FDTRACK_CLOSE,在3.4.6节介绍fdsan的时候有提到过,埋桩到android_fdsan_close_with_tag函数中,在各个链接了libc的进程中执行file close时,保证都会调用到android_fdsan_close_with_tag。
预埋桩代码的初始化部分__libc_init_fdtrack同3.4.3节介绍的__libc_init_fdsan,不再赘述。可以保证不论是动态加载libc库,还是静态加载libc库,都会调用到fdtrack静态预埋装部分代码。
_libc_init_fdtrack在调用进程内部注册信号BIONIC_SIGNAL_FDTRACK,处理函数为空函数,只实现预先占位功能。后面讲到ctor初始化时,可以看到动态加载fdtrack库时,ctor初始化时,会重新注册信号BIONIC_SIGNAL_FDTRACK处理函数。
(2) fdtrack内部实现数据结构定义
首先介绍的是android_fdtrack_event结构体,封装了发送到fd_hook的数据包
其中有重要的成员type,用来在fd_hook中区分是create还是close事件。
fdtrack内部使用全局数组stack_traces记录调用栈信息,该静态数组大小为4096。
该数组的元素类型为struct FdEntry,其核心成员就是一个可变长数组。从名字backtrace的可见,是用于记录调用栈信息。
(3) 动态加载初始化与析构去初始化
libfdtrack库动态加载时,会调用到初始化函数ctor,这是通过属性__attribute__((constructor))设定的。
该函数会完成4件事情
1)初始化调用栈记录器stack_traces的backtrace成员,分配数组大小为kStackDepth;
2)注册信号BIONIC_SIGNAL_FDTRACK的处理函数fdtrack_dump/fdtrack_dump_fatal;
3)设置__android_fdtrack_hook为fd_hook;
4)设置fdtrack使能标志。
对应的去初始化dtor,主要做了一件事情,就是将__android_fdtrack_hook设定为nullptr。
(4) fd_hook实现
根据event的类型进行相应处理:
如果是create,以fd为索引,获取对应的FdEntry,并记录分配路径的调用栈;
如果是close,以fd为索引,获取对应的FdEntry,清除调用栈记录;
其中GetFdEntry,以fd为索引,返回指向对应的FdEntry元素的指针。
(5) fdtrack_dump实现
从前面ctor的分析可知,注册的BIONIC_SIGNAL_FDTRACK信号处理函数中,会根据发送的信号附加的siginfo信息,区分处理方式,是fatal模式还是非fatal模式。
其中使用sigqueue方式发送BIONIC_SIGNAL_FDTRACK信号,且携带了正确的siginfo时,会走到fdtrack_dump_fatal里。
可以看到两个不同的接口函数,调用到同一个实现函数fdtrack_dump_impl里,通过参数区分。
fdtrack_dump_impl的核心实现,包含以下几个部分:
1)内部定义一个128个元素的静态StackInfo数组,来对全局stack_traces中记录的调用栈,进行相同调用栈合并计数(通过hash判断是否相同调用栈),并输出最多次数的分配调用栈信息。
2)调用辅助函数fdtrack_iterate,对记录的调用栈信息进行合并与排序处理。
3)如果是fatal类型的,另起一个线程主动abort,通过注释,可以看到是为了避免ART dump runtime stack
fdtrack_iterate,实现了遍历全局调用栈数组stack_traces,对其中每一项有效调用栈记录backtrace,提取出函数name与函数内offset信息,然后调用callback接口进行计算处理。
完成相同调用栈计数合并,以及找到最大计数调用栈。
我们注意到通过在入口处调用android_fdtrack_set_enabled(false),出口处调用android_fdtrack_set_enabled(prev),来实现fdtrack_iterate函数的线程安全处理。
相同调用栈判断,是通过计算调用栈中所有函数name与函数内offset的hash方式来得出的。
五、我们可以从中学到什么
android中fdtrack的实现方式非常值得我们学习。
通过静态埋桩把基础代码固化到libc中,所有链接libc的程序,都会得到静态埋桩。然后通过动态加载库来设置前面静态埋桩的钩子函数为有效函数,从而达到动态使能某个特性的目的。
这种静态埋桩,外加动态库动态使能的设计方法值的我们学习。
六、进一步思考
1)GKI后,定制化feature如何实现?
android最近几个版本,都在强力推行GKI,完全采用GKI后,vendor和OEM厂家的kernel定制化代码实现方式只能有两种选择:
对于必须要实现在内核态的代码,选择ko化,需要严格遵守KMI约束;
对于可以上移到用户空间实现的代码,上移到bionic中实现。
而对于后一种方式,fdtrack方案的设计方法非常值得我们学习借鉴。
2)fdtrack还有没有其他的使用方式?
AOSP中针对fdtrack方案,给出的使用方式示例代码,为system_server创建一个monitor线程周期检测。
那么还有没有其他的使用方式呢?
在这里给大家抛出一个问题,大家可以结合自己的实际使用场景,思考一下。
参考资料:
1.《Unix环境高级编程》
2.《存储基础——文件描述符fd究竟是什么》https://www.qiyacloud.cn/2021/04/2021-04-07/
3.android S版本AOSP源代码 https://android.googlesource.com/platform/bionic/+/refs/heads/master